ffsync 1.1.0

Chunk-level file synchronization for big files and fast links
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
# Wire Protocol

Specification for the ffsync wire protocol. Written for v0.1 (protocol
version 1: **one file pair, two peers, one TCP connection**); it tracks the
protocol across versions — changes ship with a `VERSION` bump and are
recorded in the relevant sections. Current version: **4** (v0.2: HELLO
gained `file_mtime_nanos`, see §3/§4; v0.3 adds the tree envelope, see
§16; v0.4/v0.5 work extends TREE_BEGIN additively under flag bits —
§16.7 excludes, §16.8 parallel sessions — without a VERSION bump, since
unknown flag bits are rejected and the default layouts are unchanged;
v0.6 adds §17, the QUIC carrier, also without a bump; v0.7 adds §16.9,
per-entry POSIX mode bits under a flag, with a VERSION bump — see §16.9).
§17 specifies the QUIC transport (D13 step 2): a carrier change under
the same wire bytes, no VERSION bump.

## 1. Scope and non-goals

**In scope (v0.1, extended in v0.2):**

- One explicitly named file pair per invocation (`ffsync send <file> <peer>` /
  `ffsync receive <file>` — exact CLI shape open, see §14).
- Roles: the peer with the desired ("source") content is the **sender**, the
  peer wanting to converge is the **receiver**. Roles are assigned by the CLI,
  not negotiated.
- Transport: one ordered, reliable byte stream (TCP). Plaintext for v0.1 —
  intended for LAN use or inside an SSH tunnel. TLS (rustls) is planned; see
  D13 and §14.
- Compression: zstd on chunk-data frames only.
- Resume after disconnect (receiver-authoritative, D10).
- Progress/observability derived from protocol flow (§11).
- v0.2: peer-local persistent chunk index (warm starts, D15) — §14.1; HELLO
  sub-second mtime (protocol v2).
- v0.3: directory-tree sync (D17) — one connection, sequential per-file
  conversations inside a TREE_BEGIN/TREE_END envelope (§16); mtime
  preservation on commit.

**Out of scope, per D9:**

- Metadata beyond (path, size, mtime): no xattrs, no hardlinks, no
  symlink-as-entry. (v0.3 follows file symlinks to their target type;
  dangling ones are skipped with a warning — recorded, not silent.
  v0.7 amends this once: tree sync carries POSIX mode bits under a flag
  — §16.9 — and directory symlinks/junctions are no longer recursed
  into; both still recorded, not silent.)
- Bidirectional merge / continuous sync (that is Syncthing's job; D9, D17).

## 2. Model

### 2.1 Content addressing is the only source of truth

Every chunk is identified by its 32-byte BLAKE3 hash. The file is identified by
its **chunk-tree root**: `BLAKE3(CHUNK_ROOT_PREFIX || chunk_hashes_in_file_order)`
with a 32-byte domain-separated prefix — *not* the standard BLAKE3 file hash,
whose internal Merkle tree is over fixed 1024-byte blocks rather than CDC chunk
boundaries (a standard root could not be computed from the chunk list and would
require a second full read of the file). The chunk root is computed from the
chunk list the pipeline already produces; it is order-sensitive, so all scans
must produce chunks in canonical file order (sorted by offset — hash workers
complete chunks concurrently and arrival order is not file order).

Property: two files with equal root hashes are (collision-assumption)
chunk-identical. The root is therefore simultaneously the change-detection
identity (D11), the end-to-end verification oracle (§8), and the file identity
for warm starts (§14.1). Offsets are *descriptive*, never *authoritative*.

### 2.2 One ordered wire, unordered storage

The wire is a single ordered TCP stream carrying typed, interleaved frames.
"Out-of-order" applies to **storage**, not the wire: the receiver writes chunks
to the temp file at arbitrary offsets in arrival order (D6). There is no
per-chunk parallelism on the wire in v0.1 (that is an extension for long fat
networks, §14.4).

### 2.3 The manifest describes "bytes I can offer locally"

Both roles use one message shape: the **manifest** — a list of
`(hash, offset, length)` entries describing byte ranges the *sender of the
manifest* can serve locally. Normally this is the receiver's current file. On
resume it is the receiver's partially built temp file (§10). The sender's
diff logic never needs to know *which* file a manifest describes — only that
those bytes are available at those offsets.

## 3. Framing

```
Frame := type: u8 | payload_len: u32 (LE) | payload: [payload_len]
```

All integers are **little-endian** (deliberate: native Rust order, no
conversion cost; network-byte-order tradition is not worth the code). Maximum
payload: 64 MiB. A frame must be dispatched on its type byte only; unknown
types are a protocol error (forward compatibility is by version negotiation,
not by tolerating unknown frames).

| Type  | Name           | Dir     | Payload |
|-------|----------------|---------|---------|
| 0x01  | HELLO          | both    | magic `FFSY` (4B), version u16 (=4), flags u16 (§4), role u8 (1=sender, 2=receiver), cdc_min u32, cdc_avg u32, cdc_max u32, file_size u64, file_mtime u64 (unix secs, 0 if absent), file_mtime_nanos u32 (sub-second part, 0 if absent; v0.2), path (u16 len + UTF-8) |
| 0x02  | MANIFEST_CHUNK | recv→send | hash 32B + offset u64 + length u32 (44 B) |
| 0x03  | MANIFEST_DONE  | recv→send | empty |
| 0x04  | PLAN_LOCAL     | send→recv | target_offset u64 + length u32 + source_offset u64 + hash 32B (52 B) |
| 0x05  | PLAN_REMOTE    | send→recv | target_offset u64 + length u32 + hash 32B (44 B) |
| 0x06  | PLAN_DONE      | send→recv | empty |
| 0x07  | DATA           | send→recv | hash 32B + zstd-compressed chunk bytes |
| 0x08  | VERIFY         | send→recv | root_hash 32B |
| 0x09  | COMMIT_ACK     | recv→send | empty |
| 0x0A  | NOT_MODIFIED   | both    | empty |
| 0x0B  | ERROR          | both    | code u16 + message (u16 len + UTF-8) |
| 0x0C  | TREE_BEGIN     | send→recv | flags u16 (§16.2), [parallel u16 when bit 3], [pattern section when bit 2], entry_count u64, then entry_count × tree entry (§16.2; per-entry `mode u32` when bit 4) |
| 0x0D  | TREE_END       | both    | deleted_count u32 + deleted_bytes u64 (recv→send, after deletes; send→recv variant is empty); v0.3 |
| 0x0E  | FILE_SYNC      | send→recv | empty; tree sessions only: "this file needs a conversation" (§16.4) — its absence is NOT_MODIFIED |
| 0x0F  | TREE_WORKER    | both    | sender→recv: worker_index u16 (1..K-1); recv→sender: ACK (empty) (§16.8) |
| 0x10  | TREE_FILE      | send→recv | rel path (u16 len + UTF-8) (§16.8) |

Error codes: 1=version, 2=role conflict, 3=bad frame, 4=hash mismatch,
5=io, 6=aborted. Any ERROR frame is terminal: the sender of ERROR closes the
connection after flushing; the peer must treat the transfer as failed.

## 4. Handshake and negotiation

1. Both peers connect and immediately send HELLO.
2. Validate: magic, version equality (else ERROR 1), role conflict
   (both 1 or both 2 → ERROR 2).
3. CDC parameters: the **sender's** (min, avg, max) are authoritative; the
   receiver **MUST** use them for the temp-file re-scan (§8.2 — different
   parameters produce different chunk layouts and the roots could never
   agree) and **SHOULD** use them for its own manifest scan (same-boundary
   scans maximize hash matches; a mismatch degrades to over-send, never
   corruption — the offsets in a manifest are descriptive, §3). The sender
   picks the parameters as a pure function of its file size (small-file
   adaptive chunking, D19: below 16 MiB the F1-sweep config
   16k/64k/256k, otherwise the shipped 64k/256k/1m — both peers can
   re-derive the policy, but the values on the wire are what governs). A
   receiver may keep scanning with its own parameters for its manifest
   when its file differs wildly in size; VERIFY correctness is unaffected
   as long as the *re-scan* uses the sender's parameters.
4. Fast path: if `flags.trust_times` is set (default on, D14) **and**
   `file_size`, `file_mtime` **and `file_mtime_nanos`** are equal on both
   sides, the sender sends NOT_MODIFIED and closes. Both peers are done
   without reading a single file byte. This is rsync's default semantics; it
   trusts mtime+size and can be wrong in the same ways rsync can (2-second
   FAT mtime granularity, network filesystem clocks, mtime-preserving edits).
   The nanosecond field exists because v0.1's whole-second comparison let a
   same-second same-size rewrite pass as "in sync" — a silent wrong-content
   no-op (v0.2, found by the D15 index tests; modern rsync compares
   sub-second mtimes for the same reason). `--checksum` mode clears this
   flag and forces full verification.

Flags (HELLO): bit 0 `trust_times`, bit 1 `eager` (§9.2 — designed below;
still must be 0 on the wire until §9.2 is implemented, and a peer MUST
reject a HELLO that sets it), bit 2 `resuming` (informational, §10), bit 3
`dry_run` (§7.1, implemented in v0.8 work: the sender offers a preview
session; the receiver accepts with the DRY_RUN_ACK marker before any
manifest/plan traffic). Unknown bits are rejected at decode (strict
codec).

## 5. Scan and manifest exchange (cold start)

After the handshake, both peers scan concurrently (this is the pipelined
`process_file` pipeline):

- **Receiver:** streams one MANIFEST_CHUNK per chunk of its file as the
  pipeline emits them, then MANIFEST_DONE. It does *not* need to finish before
  the sender starts anything except diffing (lazy mode).
- **Sender:** accumulates its own chunk list and root hash. It consumes the
  receiver's manifest stream into a `HashSet<Hash>` (offsets are ignored for
  diffing, D4).

The sender's diff may only start after MANIFEST_DONE. TCP backpressure makes
the ordering self-enforcing: the sender cannot have diffed a manifest it has
not fully received.

## 6. Plan generation

The sender maps each of its chunks to an action:

- Chunk hash ∈ receiver's manifest set → **PLAN_LOCAL**: the receiver already
  has these bytes; the plan entry carries the receiver's own offset for that
  hash (target_offset = sender chunk offset, length = sender chunk length,
  source_offset = manifest offset for that hash).
- Otherwise → **PLAN_REMOTE**: sender must transmit the bytes (target_offset,
  length, hash).

The sender streams PLAN_LOCAL / PLAN_REMOTE frames as it diffs, then PLAN_DONE.

### 6.1 Exact-match rule (D12)

A local match requires **(hash, length) exact match** against a manifest entry.
PLAN_LOCAL carries the chunk hash so the receiver can verify the local read
against the expected content: a stale manifest entry (content changed since
the manifest was computed) costs a clean ERROR — never silent corruption. On
resume the hash also disambiguates *which* local file an entry refers to (see
§10). The receiver never sub-slices a manifest entry, because content inside a
chunk cannot be verified without re-hashing it.

### 6.2 Boundary-shift fallback

After a modification, CDC boundaries shift between the two versions for
roughly one max-chunk-size until the rolling window re-synchronizes. In that
window, sender chunks may not match any receiver manifest entry exactly even
though the bytes exist locally. The plan therefore falls back to PLAN_REMOTE
for those chunks. Consequence: over-send is bounded near modification regions
by ~2 × max_chunk_size; correctness is unaffected. Using identical CDC
parameters on both sides (§4.3) maximizes local reuse.

## 7. Data transfer (lazy)

After PLAN_DONE, the sender streams one DATA frame per PLAN_REMOTE hash,
compressed with zstd (level 3 default). Because the wire is ordered, the
receiver has already seen the PLAN_REMOTE entry for every DATA hash when it
arrives — it can write immediately to `target_offset`, no buffering needed.

### 7.1 Dry-run preview (v0.8 work, additive)

`--dry-run` (send side) asks for a preview: the receiver executes nothing,
writes nothing, deletes nothing, and reports what WOULD happen. The wire
contract (no VERSION bump — additive frame + flag, same precedent as
TREE_WORKER/TREE_FILE):

1. The sender's HELLO carries flag bit 3 (`FLAG_DRY_RUN`).
2. A receiver implementing the mode sends an empty `DRY_RUN_ACK` (0x11)
   marker immediately after reading the HELLO — before any manifest or plan
   traffic.
3. **Old-peer contract:** a peer that ignores the bit on decode never reads
   the marker — it waits for a manifest, so the marker buffers and the
   receiver's manifest streams on normally; its plan loop then sees the
   marker as an unexpected frame and fails LOUDLY (strict decode). Nothing
   is transferred or written at any point.
4. A dry-run sender reads the marker, then the flow is normal through the
   plan. The receiver answers the PLAN itself:
   - would-change (any PLAN_REMOTE entry, or a non-layout-identical Local
     plan, or a zero-size sender file) → `NOT_MODIFIED` + `DRY_RUN_ACK`
     (would-change report). The sender skips DATA and VERIFY entirely,
     reads the report, returns `WouldChange`.
   - nothing-would-change (empty plan with matching state, or an all-Local
     plan reproducing the receiver's layout exactly) → `COMMIT_ACK` as
     usual; the sender returns `InSync`. No index is written in dry-run.
5. Tree sessions (§16.10) carry the preview as a TREE_BEGIN flag bit; the
   delete pass counts instead of removing.

The preview's zero-mutation guarantee is structural: no temp file is
created on the receiver in the would-change path, and the sender requests
no DATA.

**Receiver write order (D10, empirically decided):** the receiver executes
plan entries in order, resolving each PLAN_LOCAL chunk per hash from its union
manifest (temp file before old file) and writing it *before* receiving the
DATA frames. Local chunks are the bulk of the file and available offline, so a
crash mid-DATA leaves a temp that is mostly real data — the precondition that
makes §10 resume work. (The disconnect simulation measured the alternative:
remotes-first leaves a ~97%-hole temp where every chunk straddles a hole and
the re-scan matches nothing; locals-first recovers 30–32 of 32 MiB.)

Receivers handle duplicate DATA hashes by ignoring repeats (can only happen
with future eager mode or retries).

**End of transfer discipline:** the receiver knows the transfer is complete
when (a) PLAN_DONE has been seen, (b) every PLAN_REMOTE hash has a DATA frame,
and (c) VERIFY has arrived. It must not finalize before (a)–(c) all hold.

## 8. Verification and commit

1. Receiver pre-allocates the temp file (`<target>.ffsync-tmp`, D6) to the
   sender's `file_size` at the start of plan execution.
2. On completion (§7), the receiver computes the BLAKE3 root hash of the temp
   file and compares it to the VERIFY frame.
3. Match → fsync, atomic rename over the target path, send COMMIT_ACK. Both
   peers are done.
4. Mismatch → delete the temp file, send ERROR 4. The original file is
   untouched. Retry is the sender's decision (fresh connection).

**CDC parameters in verification (v4, D19):** the root is computed over the
chunk list produced by *one* parameter set — the sender's. The receiver's
temp re-scan therefore uses the sender's HELLO parameters
(`cdc_min/cdc_avg/cdc_max`), not its own defaults; two differently-cut
chunkings of identical content yield different roots, so a receiver that
ignores the sender's parameters can never verify. (Before D19 both peers
hardcoded the shipped constants, which is why this was invisible.)

**Already-in-sync edge case:** if the sender's plan is empty (no chunks at
all) the sender still sends VERIFY. The receiver then hashes its *current*
file (it already has the root from its own scan — free) and compares: match →
COMMIT_ACK without ever creating a temp file; mismatch → the receiver's file
content does not match what its manifest claimed (corrupted index/trust
assumption) → ERROR 4. This makes "files identical despite metadata mismatch"
discoverable without any transfer, and it is the hook the future index plugs
into (§14.1).

**All-Local plans (v3, found by the static-binary smoke test):** an empty plan
is *not* the only zero-wire-bytes shape. A receiver whose file contains the
sender's chunk bytes at *different positions* (a prefix or shifted region of
the sender's file) yields an all-Local plan — Locals are dedup'd by hash, not
by position. The receiver must then **execute** the plan (write chunks at the
sender's offsets from wherever its manifest holds them) and both peers report
the executed plan (Transferred, 0 wire bytes). The receiver skips the rewrite
only when the plan's Local spans exactly reproduce the receiver's own chunk
layout (same count, same offsets, same lengths — identical files under
`--checksum`, including periodic content whose repeated hashes collapse in
the diff map; the sender mirrors the check against the raw manifest chunk
list). VERIFY guards every path regardless.

## 9. Diff strategy (D8) — lazy now, eager reserved

### 9.1 Lazy (v0.1, default)

Receiver finishes its scan before the sender sends anything. Zero wasted
bytes, one scan-length of latency before the first data byte. Right for slow
links, where over-sending is fatal.

### 9.2 Eager (designed, not yet implemented — flag bit 1)

Sender diffs against the *partial* manifest set and streams DATA for hashes
confirmed missing so far. Bytes start moving during the scan. A late manifest
entry may match an already-sent chunk → bounded over-send. Requires the
receiver to buffer pre-plan DATA by hash (the §7 no-buffering guarantee is a
lazy-mode property).

**The knob is a HELLO handshake, not a configuration guess.** The sender
sets flag bit 1 (`eager`) as an OFFER (`--eager` CLI flag, default off);
the receiver mirrors the bit in its HELLO iff it implements eager mode.
Eager is active iff both peers set the bit.

**Compatibility (loud-failure doctrine).** A peer that receives a HELLO
with `eager` set must reject it with ERROR(Version) while it does not
implement §9.2 — the two peers would otherwise disagree on DATA timing,
which is a desync hazard. An old receiver never sees early DATA (nothing
is sent at HELLO time), so the fallback to lazy is silent and free; only
the offer itself is rejected loudly. Once both peers implement the mode,
the bit is active.

**Over-send is bounded by position, not by hope.** The sender declares a
chunk missing only when its manifest consumption has advanced past
`chunk.offset + 2×cdc_avg` without seeing the chunk's hash — or the
manifest is done. This is exactly the §6.2 boundary-shift condition;
elsewhere a late manifest match still rescues the chunk. Eager's extra
cost over lazy is therefore bounded by ~2×cdc_avg per chunk inside
modified regions (same constant as the boundary-shift fallback).

**Receiver buffering, bounded.** Pre-plan DATA is buffered by hash (the
sender dedups by hash before offering; duplicate DATA hashes are ignored
as today) under a fixed 64 MiB cap (§12 spirit). Overflow is a loud ERROR
and a lazy retry with a clean temp — never a spill file, never a silent
degradation. (This resolves §13 question 2.)

**Ordering and resume.** Writes happen in arrival order under eager —
D10's locals-first ordering is a lazy-mode property (§7), and resume is
unaffected because the temp re-scan is content-addressed (§10).

**Status.** Implemented (2026-10-24, post-1.0). The bench cell
(`bench --eager --rtt-ms {0,30} --seed 42`) measures paired lazy-vs-eager
sessions over the RTT-modeled duplex; over-send was 0 bytes in every run
(the §6.2 position gate rescues late matches exactly as designed), and
eager cut wall time 18–33% at 0 ms RTT and 12–18% at 30 ms RTT (medians
455→368 ms and 596→488 ms on the 128 MiB modified fixture, 3 paired runs
each). The knob is `--eager` on both peers; a declining peer fails the
session loudly (the sender checks the receiver's HELLO mirror). Wire
contract notes: eager offers are deduplicated by hash (repeated content
chunks are offered once; the receiver's by-hash buffer is a cache that
serves every plan entry with that hash), and the sender's data phase
skips hashes already offered (re-sending would corrupt the frame stream
the receiver reads at VERIFY).

## 10. Resume (D10, decided and implemented)

Resume is **not a separate protocol phase** — it reuses the normal flow with a
different manifest source. Decided on simulation data (see D10 in
`Design Decisions.md`); receiver-authoritative:

1. Receiver persists no manifest file: the **temp file's content is the
   resume state** (locals-first write order per §7 makes it mostly real
   data after a crash).
2. On reconnect, the receiver checks for a temp file. If present (HELLO
   carries `FLAG_RESUMING`): it **re-scans the temp file** and streams the
   **union manifest** — temp chunks first, then old-file chunks the temp
   does not cover. If absent: cold start, manifest of the old file.
3. The sender diffs its source against whatever manifest it receives,
   exactly as in §5–7. PLAN_LOCAL entries carry the hash; the receiver
   resolves each local source per hash (temp before old file) and verifies
   the read against it. The sender needs no state from the previous
   attempt.
4. Verification (§8) is unchanged and is the final oracle: a stale or wrong
   manifest can cost re-sends, never corruption.
5. **Empty plan on resume:** if the previous attempt died after the last
   DATA byte, the reconnect plan is empty but the temp is complete; the
   receiver compares the temp's root against VERIFY and commits it (atomic
   rename) on match.

**Why re-scan the temp instead of journaling written offsets:** the temp
file's content *is* the state, re-scanning reuses one code path, and CDC
re-chunking of correct chunk data reproduces matching hashes except in
boundary-shift regions (same bounded over-send as §6.2). A write-journal is a
later optimization, not v0.1 (§13).

## 11. Progress and observability

No dedicated progress frames. Every phase's progress is derivable from frame
flow, which was a design requirement (silent hashing was the project's
origin, D5):

- Scan: MANIFEST_CHUNK count vs. expected chunk count (receiver knows its file
  size; chunks per byte ≈ 1/avg_size).
- Diff: plan entries received vs. sender's file size covered.
- Transfer: DATA bytes decompressed vs. total PLAN_REMOTE length.
- The CLI SHOULD display: current phase, bytes hashed / total, bytes missing /
  total, effective throughput per phase. A peer that is scan-bound says so:
  "hashing (disk-bound), network idle" is the exact failure mode this tool
  exists to eliminate and must never be silent again.

## 12. Errors and resource limits

- Any ERROR frame: terminal, both sides close. Documented codes in §3.
- Truncated connections: both sides treat as failure; receiver keeps temp +
  manifest (that is the resume point); sender keeps nothing.
- Frames larger than 64 MiB payload: protocol error.
- Receiver MUST enforce: DATA total ≤ PLAN_REMOTE total; temp file never
  exceeds sender's file_size. A sender violating these is malicious or broken
  → ERROR 3.

## 13. Open questions

1. **Index staleness policy** (§14.1): exact trust rules for cross-run
   manifests; relationship to mtime granularity problems.
2. **Eager-mode buffer cap** (§9.2): resolved by design — fixed 64 MiB cap,
   overflow = loud ERROR + lazy retry (§9.2). Implementation of eager mode
   itself remains open, gated on the eager-mode bench cell.
3. **Resume write-journal** vs. temp re-scan: at what file sizes does the
   re-scan cost matter?
4. **zstd batching:** per-chunk frames waste some compression ratio at ~1 MiB
   average chunk size; batching multiple chunks per frame trades ratio against
   pipelining granularity. Measure before deciding.
5. **Plan bandwidth symmetry:** the plan is sender-computed (§6). A
   receiver-derived plan (sender sends only its manifest) would halve control
   traffic but move work and complexity to the receiver. Revisit if plan frames
   ever show up in profiles (they will not for typical chunk sizes).
6. **Parallel data streams** for long fat networks (§14.4): how many, how
   scheduled.

## 14. Extension points — status

1. **Persistent index:** IMPLEMENTED (D15, v0.4 — `FFSYIX02` sidecar
   files; the INDEX-frame idea was dropped in favor of the on-disk
   sidecar, same trust model: size+mtime+CDC-parameter validation).
2. **Eager diff:** DESIGNED, not implemented (§9.2; gated on the
   eager-mode bench cell — D8's doctrine).
3. **TLS:** IMPLEMENTED (D16, v0.5 — rustls TLS 1.3, mutual TOFU
   fingerprint pinning; QUIC followed in v0.6, §17).
4. **Parallel chunk streams:** IMPLEMENTED (v0.7, §16.8 — per-file
   conversations over K TCP connections; on QUIC they ride streams,
   §17).
5. **Hash truncation:** reserved, not implemented. BLAKE3 supports
   shorter outputs; truncating wire hashes saves bandwidth at a
   quantifiable collision cost. Only with explicit analysis; the wire
   always sends 32 bytes.
6. **Tree sync:** implemented in v0.3 — §16 (D17), including the
   parallel per-file streams item above.

## 15. Worked example (3-chunk toy file)

Sender file S = [A][B][C] (3 chunks, hashes hA/hB/hC). Receiver file R = [A][X]
(hA/hX). Both use identical CDC params.

```
S→R: HELLO {role=sender, size=3 chunks, mtime=T1}
R→S: HELLO {role=receiver, size=2 chunks, mtime=T2}
     (fast path not taken: size differs)
R→S: MANIFEST_CHUNK {hA, off 0, len 1}      # R scans, streams
R→S: MANIFEST_CHUNK {hX, off 1, len 1}
R→S: MANIFEST_DONE
S→R: PLAN_LOCAL  {target 0, len 1, src 0}   # hA present in R
S→R: PLAN_REMOTE {target 1, len 1, hB}      # hB missing
S→R: PLAN_REMOTE {target 2, len 1, hC}      # hC missing
S→R: PLAN_DONE
S→R: DATA {hB, zstd(bytes-of-B)}
S→R: DATA {hC, zstd(bytes-of-C)}
S→R: VERIFY {root(S)}
R:   writes B→offset 1, C→offset 2 of temp; local-copies A→offset 0;
     root(temp) == root(S) → fsync, rename, COMMIT_ACK
R→S: COMMIT_ACK
```

Same flow with R = [A][B][C] but mtime differs: plan comes back empty, VERIFY
carries root(S), receiver compares against its own root → match → COMMIT_ACK
with no temp file ever created.

## 16. Tree sync (v0.3, D17)

One connection, many per-file conversations. The per-file protocol (§4–§8) is
unchanged; the tree layer wraps and sequences it.

### 16.1 Session shape

```
S→R: TREE_BEGIN   (tree manifest + tree flags)
R→S: TREE_BEGIN   (receiver's tree manifest, same framing)
      both sides triage (§16.3) — no wire traffic
      for each file needing sync (deterministic order, §16.4):
        S→R: per-file HELLO (path = relative path, §4)
        ... per-file protocol §5–§8 unchanged ...
      (deletions, §16.5)
S→R: TREE_END     (empty payload)
R→S: TREE_END     (deleted_count, deleted_bytes)
```

The sender's TREE_BEGIN is authoritative for direction: the sender's tree is
the desired state, the receiver converges toward it (`rsync -r --delete`
semantics made explicit; `--no-delete` via tree flags, §16.2). Both peers
send their full tree manifest before any per-file conversation starts —
triage is local, deterministic on both sides from the same two manifests.

A v2 peer receiving TREE_BEGIN fails with an unknown-frame ERROR (v2 framing
is strict, §3) — the version check in HELLO makes this a clean rejection
before any tree state exists. Protocol version 3 is only advertised by
peers implementing this section.

### 16.2 Tree manifest and flags

TREE_BEGIN payload: `entry_count u64`, then `entry_count` tree entries:

```
TreeEntry := rel_path (u16 len + UTF-8) | kind u8 (0=file, 1=dir)
           | size u64 | mtime u64 (unix secs, 0 if absent)
           | mtime_nanos u32 (0 if absent)
```

Entries are sorted by `rel_path` (byte order) — deterministic iteration on
both sides without negotiation. `kind=1` (dir) entries carry size=0 and
exist only to create empty directories on the receiver (a directory with
files needs no entry; its files imply the path). Paths are relative to the
synced root, `/`-separated regardless of platform, and must not contain
`..` components (protocol error otherwise — path traversal is a protocol
violation, not a filesystem concern).

Tree flags (in TREE_BEGIN, u16): bit 0 `checksum` (D11 tree-wide: no
metadata fast path on any file), bit 1 `no_delete` (§16.5).

### 16.3 Triage (local, no wire traffic)

Both peers, after exchanging TREE_BEGINs, compute locally:

- **skip**: same rel_path on both sides, both files, same size, same
  mtime secs + nanos (and neither side set `checksum`) — exactly the §4
  fast-path rule, applied per file from metadata alone. Also: same path
  and both dirs.
- **sync**: same path, both files, size or mtime differs (or `checksum`
  set) → per-file conversation (its VERIFY is the medium diff; the
  workflow doc's Phase 2 is absorbed there).
- **transfer**: file exists on sender only → per-file conversation with an
  empty receiver file (the receiver creates it; empty-plan handling of a
  zero-byte sender file truncates a non-empty receiver file to empty —
  fixed in v3, previously an ERROR).
- **delete**: file exists on receiver only → deferred to §16.5.
- **conflict**: file on one side, dir on the other → the dir side is
  removed and the file synced (the sender's tree is authoritative in both
  directions of this asymmetry: sender-file/receiver-dir deletes the dir,
  sender-dir/receiver-file deletes the file, then creates the dir if
  needed). Files inside a conflicted dir are covered by the delete rule.

### 16.4 Execution order

Per-file conversations run in the sender's manifest order (sorted by
rel_path). Skipping happens on both sides independently — the sender
initiates a conversation only for files in its `sync`/`transfer` set; the
receiver expects conversations exactly for the same set (computed from the
same two manifests), so no per-file negotiation is needed. A receiver that
disagrees (it must not, given deterministic triage) fails loudly on the
first unexpected HELLO.

Because the sender's fast-path decision (size+mtime equal, both trust
times) is known *before* it would demand the receiver's manifest, tree
sessions make the branch explicit: after the per-file HELLO exchange, the
sender sends either NOT_MODIFIED (skip; conversation over, no manifest
streamed) or FILE_SYNC (empty; conversation proceeds — the receiver then
streams its manifest as usual). The receiver reads exactly one frame at
that point; no write races, no broken-pipe tolerance needed inside a
session. Single-file sync keeps its original drain-based flow (§4) —
there, connection end *is* the session end.

Within one file's conversation, everything is unchanged from §5–§8 —
including resume (a mid-tree failure leaves that file's temp file; the
next session's triage sees the file still differing and the per-file
resume logic takes over), warm indexes (D15), and the fast path.

Deletions happen **after** all conversations succeeded. A failed
conversation aborts the session (ERROR, terminal per §3) before any
delete is applied — fail-closed: a failed transfer never destroys data.

**ERROR at any read point (v3):** a peer that aborts mid-session sends
ERROR (code `Aborted`) before dropping the stream, at whatever point it
dies — instead of letting the peer time out. Receivers that expect a
HELLO (or any typed frame) must classify an incoming ERROR as the peer's
abort reason, not as a framing violation. A peer MAY still die without
announcing (process kill, network cut); the socket timeout remains the
last line of defense, which is why it exists and stays generous. Delete
targets destroyed as a side effect of conflict resolution (§16.3) are
tolerated as already-gone (`NotFound`/`NotADirectory`) during the delete
pass.

### 16.5 Deletions

After the last per-file COMMIT_ACK, the sender sends TREE_END (empty).
The receiver deletes every file in its `delete` set (skipped when
`no_delete` is set), then removes now-empty directories bottom-up
(within the synced subtree only), then replies TREE_END with
`deleted_count u32 + deleted_bytes u64` for the summary line. The sender
reads the reply and exits; the receiver exits after sending. The sender's
TREE_END doubles as the "transfers done, please finalize" barrier — the
receiver must not delete anything before it (a sender aborting
mid-session leaves the receiver's tree intact apart from synced files).

Delete safety is bounded by the same trust decision as every write in the
session: the receiver already pinned the sender's fingerprint (D16).

### 16.6 mtime preservation (v3, load-bearing for trees)

On every commit (COMMIT_ACK path), the receiver sets the target file's
mtime to the sender's HELLO `file_mtime`/`file_mtime_nanos` **before**
sending COMMIT_ACK, using `File::set_modified` (std, all platforms). The
sender's HELLO mtime is read before its scan starts (unchanged §4). Both
peers then record the same mtime in their D15 indexes, so the next tree
sync's triage (§16.3) skips the file — the tree fast path fires without
reading a byte. Without this, every synced file's mtime would change at
commit and no tree sync would ever skip.

Single-file sync adopts the same behavior under protocol v3 (observable
change: the receiver's file now carries the sender's mtime after a sync;
this matches rsync's default `--times` behavior and is what users expect
from a sync tool — recorded in D17).

### 16.7 Excludes (v3, `--exclude` / `--exclude-from`)

Excludes remove paths from a tree session entirely: an excluded path is
invisible to triage on both peers — it is never transferred, never
deleted, never reported. This is rsync's model: exclude patterns filter
what the session sees, and (because receiver-only paths that are excluded
never enter the receiver's manifest either) excluded files on the receiver
are protected from deletion.

**Ownership — the sender's patterns rule.** Exclude patterns travel in the
sender's TREE_BEGIN; the receiver applies exactly those to its own scan.
The receiver's own patterns (if any) never touch manifests — they only
protect paths from the receiver's delete pass (§16.5), so both peers
triage identically from the same two filtered manifests (§16.4's
determinism contract is untouched).

**Wire form.** TREE_BEGIN gains flag bit 2 `excludes`. When set, the
payload after `flags u16` carries the pattern section *before*
`entry_count u64`:

```
pattern_count u32
pattern_count × pattern (u16 len + UTF-8)
entry_count u64
entry_count × TreeEntry (§16.2)
```

A peer that did not send `excludes` but receives the flag must fail
(Malformed): the two peers would filter differently, desyncing the
per-file conversation sequence — this is a loud protocol error by design.
Peers that never set the flag parse exactly the §16.2 layout.

**Pattern grammar** (matched against the wire rel path, always
`/`-separated; case-sensitive; no backslash escapes — a `\` in a pattern
is a parse error):

- A pattern containing `/` anywhere except a trailing one is **anchored**:
  it matches from the tree root (`/build` matches only the root entry
  `build`; `doc/*.md` only files directly in `doc/`).
- A pattern without `/` matches the **basename** of any entry at any depth
  (`*.tmp`, `node_modules`).
- `*` matches any run of characters except `/`; `?` matches exactly one
  character except `/`; `[abc]`, `[a-z]`, `[!a-z]` are character classes
  within a segment (an unterminated `[` is literal).
- `**` as a *complete segment* matches any number of segments, including
  zero (`logs/**/*.log` matches `logs/a.log` and `logs/a/b/a.log`;
  `a/**/b` matches `a/b`). `**` inside a segment collapses to `*`.
- A trailing `/` restricts the pattern to **directories** (`build/`).

**Scanning rule (both peers):** an excluded directory is pruned — its
whole subtree never enters the manifest (a negation under a pruned
directory cannot re-include anything — see the grammar note below; to
re-include inside an excluded directory, exclude its children
(`target/*`) instead of the directory itself (`target/`)). An excluded
file is skipped. The scan-sidecar filter (`*.ffsync-index` /
`*.ffsync-tmp`, §16.2) takes precedence and is not user-visible.

**Delete-pass protection (receiver).** A path is skipped by the delete
pass when the path itself matches one of the receiver's patterns, or when
any of its ancestor directories matches one (an excluded directory
protects its whole subtree from deletion). Conflict resolution (§16.3) is
not gated on excludes — the sender's kind change is explicit and
authoritative.

**Negation (v0.7.1, first-match-wins).** A pattern whose first character
is `!` is a **re-include**: it removes paths from the exclusion set
rather than adding them. This upgrades the evaluation model from
any-match to rsync's **ordered first-match-wins**: patterns are checked
in the order given (CLI flag order; `--exclude-from` lines in file
order, at the position of the flag), and the first pattern that matches
a path decides — exclude, or (negation) include. A path matching no
pattern is included. For a set without negations this is exactly the
old any-match behavior (the first matching pattern is an exclude, as
they all are), so existing pattern sets and old peers' expectations are
unchanged. A leading `!` in a literal filename is written `!!foo`
(strip one `!`, the rest is the pattern — there are no backslash
escapes); the class spelling for a literal `!` member is `[]!]`
(POSIX: `]` first in a class is literal).

- **Ordering is the wire's job too:** patterns travel as an ordered
  list; both peers evaluate the same order. The receiver's own patterns
  (delete-pass guard) follow the same rule locally.
- **Delete-pass protection** inherits the semantics through the verdict:
  an ancestor directory protects its subtree from deletion iff its
  first-match verdict is *exclude* — a negated ancestor is not an
  exclusion and therefore protects nothing.
- **Wire form:** TREE_BEGIN gains flag bit 5 `negation`, set by the
  sender iff any of its patterns starts with `!`. No payload change (the
  pattern section layout is unchanged; only the interpretation of the
  strings is flag-gated). No VERSION bump: no existing layout changes
  and the default (no negations, no bit) is byte-identical to before.
  The bit's purpose is compatibility: a v3/v4 peer that receives a
  negation-carrying session rejects the unknown flag bit loudly
  (Malformed) instead of parsing `!foo` as a literal basename pattern —
  the two peers would filter differently and desync the conversation
  sequence, so the loud failure is the correct one. A session without
  negations interoperates with any peer.

### 16.8 Parallel per-file conversations (v4 flag, D13 step 1)

A tree session's conversations are independent of each other: each is a
complete §4–§8 state machine over one path. Sequential execution costs
~3 round trips per conversation (HELLO, manifest→plan, VERIFY→COMMIT_ACK)
— on a WAN, a tree with many changed files spends most of its wall time
waiting. §14.4 reserved parallel streams for exactly this.

**K connections, one conversation at a time each.** The session runs over
K connections total: connection 0 is the *control* connection (the one
TREE_BEGIN/TREE_END and the delete pass live on), connections 1..K−1 are
*worker* connections. Each connection carries at most one conversation at
a time; the per-file frames themselves are byte-identical to §4–§8. The
sender partitions its conversation set round-robin over the sorted list
(conversation *i* → connection *i* mod K — deterministic, reproducible).

**Wire form.** TREE_BEGIN gains flag bit 3 `parallel`. When set, the
payload carries `parallel u16` (K, the total connection count) right
after `flags` and before the optional excludes section:

```
flags u16
parallel u16      (only when flag bit 3 set; 2..=16)
[pattern section] (only when flag bit 2 set)
entry_count u64 ...
```

`parallel` absent (flag unset) means K=1 — the §16.2 layout, unchanged.
**A peer MUST reject unknown flag bits** (Malformed) — flag-gated fields
are only safe if unknown bits are errors. A v3 peer receiving a parallel
TREE_BEGIN misparses and fails loudly at TREE_BEGIN, before any tree
state exists (same rejection semantics as v2↔v3); a v4 peer with
`--parallel 1` interoperates with v3 peers fully. K is
sender-authoritative; the receiver mirrors the value in its own
TREE_BEGIN. Valid range 2..=16 (a session announcing 0, 1, or >16 is a
protocol error).

**New frames (worker connections only).**

```
S→R: TREE_WORKER (worker_index u16, 1..K-1)   on each worker connection
R→S: TREE_WORKER_ACK (empty)
S→R: TREE_FILE (rel path, u16 len + UTF-8)    before each conversation
      ... §4–§8 per-file conversation, unchanged ...
```

The sender opens its worker connections AFTER sending TREE_BEGIN on the
control connection (the receiver accepts them after reading TREE_BEGIN
and scanning its own tree — no deadlock: each side's blocking step is
unblocked by the peer's next step). Workers connect in ARBITRARY order
(the sender's threads race); the receiver accepts any connection, reads
its TREE_WORKER, places it by the ANNOUNCED index, and verifies the set
is exactly {1..K−1} with no duplicates. Each worker is ACKed before the
conversation phase starts.

**Membership, not order (§16.4 amendment for parallel sessions).** In a
parallel session the receiver does NOT expect conversations in manifest
order. Every conversation — on worker connections AND the control
connection — is named by a preceding TREE_FILE; the receiver checks the
path is in its expected conversation set and removes it — every path
exactly once. The control connection's service loop is the receiver's
main thread: it reads TREE_FILEs (serving each) until the sender's
TREE_END arrives THERE — in a parallel session the control connection
carries partition 0's conversations plus that TREE_END, so the §16.5
barrier read is already satisfied when the conversations phase ends (the
receiver then joins its worker threads, requires the membership set to
be empty, and proceeds to deletions). At session end the set must be
empty (a worker that died leaves it non-empty, but then the session has
already failed with that worker's error). Within one connection,
conversations still run in the sender's sorted partition order.

**Failure semantics (fail-closed, unchanged in spirit).** A failed
conversation aborts its connection (ERROR `Aborted`, §16.4). Workers stop
before *starting* the next conversation once any worker has failed
(in-flight conversations finish). The sender then reports ERROR on the
control connection; the receiver applies deletions only after ALL
conversations succeeded — a failed session never destroys data. Tree
flags, excludes, resume, warm indexes, and mtime preservation are
per-conversation concerns and work unchanged on any connection.

**Not async.** This is native threads + blocking I/O per connection - 
no async runtime enters the tree (the D13 amendment's constraint). QUIC
(D13 step 2) can later substitute the transport: a QUIC stream is
`Read + Write`, so worker connections map onto streams without touching
this layer again. That substitution is specified in §17.

### 16.10 Dry-run preview (v0.8 work)

`--dry-run` in tree mode is a TREE_BEGIN flag bit (bit 6,
`TREE_FLAG_DRY_RUN`), mirrored by the receiver like every session flag:
the sender announces it, the receiver mirrors it, and both run the normal
TREE_BEGIN exchange and triage. Then:

- The sender skips the per-file conversations entirely and goes straight
  to its TREE_END barrier; the receiver skips its conversation loop and
  its TREE_END barrier read consumes the sender's frame as usual (§16.8
  membership discipline is untouched).
- The receiver's delete pass runs in count-only mode: excluded and
  protected paths are skipped exactly as in a real session, everything
  else is counted (deepest-first order preserved), and the TREE_END
  delete report carries the WOULD-delete numbers. Nothing is removed,
  no directories are created, no kind-conflict cleanup happens.
- The sender's preview outcome reports the conversation count from its
  own triage (files that WOULD transfer), a sender-side byte estimate,
  and the receiver's would-delete report.

Old peers reject the unknown bit loudly at TREE_BEGIN decode (§16.2
strict flag policy) — the same failure shape as every other flag-gated
feature.

### 16.11 Eager diff in tree sessions (v1.0.x work, D8 extended)

`--eager` in tree mode is a TREE_BEGIN flag bit (bit 7,
`TREE_FLAG_EAGER`), announced by the sender. The receiver mirrors the bit
in its own TREE_BEGIN **iff it also opted in with its own `--eager`** —
unlike the mirrored metadata/negation/dry-run bits, eager is an opt-in,
not a session property: a receiver must never accept pre-plan DATA it
did not agree to buffer. Eager is active for the whole session iff both
peers set the bit; every per-file conversation (sequential §16.4 and
parallel §16.8 workers alike) then runs the single-file §9.2 machinery
inside the normal conversation frame order — the position gate, the
hash-keyed pre-plan DATA buffer with its 64 MiB cap, and the
offer-dedup contract (§9.2) are reused unchanged; no new frame types.

A declining receiver converges lazily (silent fallback, same as the
single-file offer), so mixed-version pairs and opt-out pairs stay
compatible. The flag joins `TREE_FLAG_KNOWN`; old peers reject it loudly
at decode — correct, since a peer that cannot parse bit 7 must not be
asked to interpret conversations that ride it.

### 16.9 File metadata: POSIX mode bits (v4, D9 amendment)

**Scope decision (D9 amendment, 2026-10-23).** Tree sync gains one metadata
axis: the POSIX mode bits (permissions + setuid/setgid/sticky). Xattrs and
hardlinks stay out of scope (xattrs: platform-fragmented semantics for
little sync value here; hardlinks: a linked file would need the same
chunk list under two paths, breaking the one-path-one-chunk-list identity
model that D15 indexes and per-file verification are built on).
Single-file mode is unchanged (metadata rides the tree manifest only —
the asymmetry is documented, and HELLO keeps its layout).

**Wire format (additive, flag-gated — the §16.7/§16.8 pattern).**
TREE_BEGIN flag bit 4 `metadata`: when set, each tree entry in this
TREE_BEGIN carries an additional `mode u32` field:

```
TreeEntry (metadata flag) := ...existing fields... | mode u32
```

`mode u32` carries the low 12 bits of the POSIX mode (rwxrwxrwx +
setuid/setgid/sticky, `0o7777` mask), encoded as the raw st_mode low bits
— i.e. the value `unix::fs::MetadataExt::mode()` masked with `0o7777`,
or 0 where the platform has no such concept. Flag bit 4 joins
`TREE_FLAG_KNOWN`; decoders reject it when unknown (old receivers fail
loudly against new senders — correct, because triage inputs must be
identical on both peers).

**Semantics.**

- **Scan:** `mode` is read at scan time on every platform; on Windows and
  any platform without POSIX modes it is 0.
- **Triage:** mode differences do NOT trigger sync by themselves. Triage
  inputs stay (path, kind, size, mtime) — adding mode to the skip rule
  would make a chmod-only change invisible to the skip decision but
  visible to the metadata pass, splitting the deterministic-triage
  invariant. Instead: mode rides along with every conversation the
  existing triage already starts, and a **chmod-only tree change**
  (same size+mtime everywhere, differing modes) is simply not synced —
  same trade rsync's default fast path makes. The metadata is best-effort
  convergence, not a change detector. (`--checksum` still forces full
  per-file conversations, which then also apply metadata.)
- **Apply:** after a file's reconstruction succeeds and before the mtime
  preservation write (§16.6), the receiver applies the sender's mode:
  on unix, `fchmod`-equivalent via `std::os::unix::fs::PermissionsExt`
  (mode masked `0o7777`); on Windows, the no-write bits
  (`0o222` for owner/group/other) map to clearing the read-only
  attribute — write-enable always wins (an attribute cannot express
  read-denial; documenting the lossy direction is the honest choice).
  Directories: mode applies to created directories at creation time;
  pre-existing directories keep their mode (the receiver's directory is
  not replaced — matching the "dir with files needs no entry" rule).
- **Order is load-bearing:** mode write → mtime write → stat → D15 index
  write. The index must record the post-metadata mtime or every
  metadata-synced file re-syncs (§16.6's ordering rule, extended one
  step earlier).
- **Index (D15): no change.** The index stays (chunk list, root, size,
  mtime): triage never reads mode (chmod-only changes are accepted as
  not-synced — the §16.3 invariant), the sender's manifest mode comes
  from a fresh metadata scan at session start (never from the index —
  the tree scan is metadata-only and always current), and the per-file
  warm path serves chunks, not metadata. Applying mode before mtime
  (above) keeps the index's recorded mtime correct without mode ever
  touching the index. (An earlier draft of this section bumped the index
  codec for a mode field; the implementation review dropped it — nothing
  consumes it.)
- **Determinism:** both peers derive the same triage from the same two
  manifests; the metadata section changes only what rides *with* a
  conversation, never *which* conversations exist. The §16.3 invariant
  is untouched.

**Symlinks (D9 amendment — policy, no wire change).** The §1 behavior is
restated and sharpened: symlinks to files are followed and synced as
their target type; symlinks to directories are followed but **never
recursed into as directories during scan** (the scan reads the link's
own metadata, sees "symlink", and skips it with a warning) — this closes
the junction/junk-symlink recursion hazard on Windows, where a junction
needs no privileges and can point at an ancestor. Dangling symlinks are
skipped with a warning (unchanged). Sync-as-entry (link + target on the
wire) remains out of scope: Windows symlink creation needs privilege or
developer mode, and target validation would add a traversal surface for
a feature ffsync's niche (big-file sync) does not need.

**Version:** protocol v3 → **v4** (this flag-gated field changes the
per-entry layout, so it takes the bump; the earlier additive extensions
— §16.7 excludes, §16.8 parallel, §17 QUIC — shipped without one because
they changed no existing layout. Unknown flag bits were already
rejected, making the migration path the loud-failure one the protocol
prefers).

## 17. QUIC transport (D13 step 2, v0.6 work)

QUIC substitutes the transport underneath the existing session shapes —
it changes **how bytes move**, not **which bytes move**. Every frame,
every handshake, every state machine in §4–§16 is unchanged: a QUIC
stream is an ordered, reliable byte stream, exactly the §2 assumption
framing and TLS-over-TCP already make. This section specifies the
mapping, the security model, and what actually differs.

### 17.1 Relationship to the session shapes

- **Single-file sessions (§4–§8) and sequential tree sessions (§16.1–
  16.6):** one QUIC *connection*, one bidirectional *stream* per
  conversation (single-file: exactly one stream). The stream plays the
  role the TLS-over-TCP stream plays today.
- **Parallel sessions (§16.8):** one QUIC connection; conversation
  *i*'s stream plays the role of connection *i*. K=1 is the sequential
  shape. The TREE_WORKER/TREE_FILE membership protocol is UNCHANGED —
  the sender still announces every conversation, the receiver still
  validates each announced path exactly once; what was "the worker
  connects" becomes "the worker opens a stream." A v4 peer over QUIC
  interoperates with a v4 TCP peer (the wire bytes are identical; only
  the carrier differs), so no VERSION bump and no new flag bits.
- **Streams are cheap** (no handshake, no kernel socket): the §16.8
  cap K=2..=16 remains the wire-protocol cap, but implementations MAY
  run more conversations than they have streams in flight; this spec
  keeps one stream per conversation partition (deterministic, matches
  the TCP shape 1:1).

### 17.2 Security model (D16 mapping)

QUIC in Rust (quinn) mandates TLS 1.3 **inside the transport** — there
is no plaintext QUIC. The D16 model maps directly, replacing the
TLS-over-TCP layer rather than stacking on it:

- **Identity:** the same self-signed certificate (raw-DER
  `identity.bin`, rcgen) is the QUIC endpoint's certificate. One
  identity per peer, same file, same codec.
- **Mutual TOFU pinning:** the QUIC client verifies the server cert's
  SHA-256 fingerprint against the pins set (Pinned or FirstUse), and
  the QUIC *server* requires client certificates and pins them the
  same way. The presented fingerprint is surfaced to the CLI after the
  handshake completes, exactly like `transport::client_session` /
  `server_session` do today — the verifier never makes trust
  decisions by itself.
- **`--plaintext` semantics:** QUIC has no plaintext mode. `--plaintext`
  on a QUIC invocation is an error ("QUIC is always encrypted; use TCP
  for plaintext") — plaintext remains a TCP-only concern (LAN/SSH).
- **Fingerprint format, pins file, `trust`/`untrust`/`fingerprints`
  commands: unchanged.** Pins are a set, not per-transport entries; a
  pinned fingerprint may connect over TCP or QUIC in either role.

### 17.3 Runtime boundary (D3 amendment)

tokio enters the tree at the edges only: quinn is an async library,
so `transport::quic` owns a small multi-thread runtime that drives
endpoint machinery (connection state, retransmit timers, keep-alives).
The public seam stays synchronous — a `QuicStream` adapter implements
`Read + Write + Send` by driving quinn futures to completion
(`block_on` at the adapter boundary), so `protocol::sync` and
`protocol::tree_sync` remain 100% sync, and the data plane (mmap,
chunker/hash worker threads, crossbeam channels, reconstructor)
remains native-thread and runtime-free. `ffsync::protocol` MUST NOT
depend on tokio; only `transport` and the CLI edge do. The §16.8
worker model is unchanged: one native thread per conversation, each
driving its stream through the sync adapter.

### 17.4 What QUIC actually buys (and what it does not)

- **One handshake instead of K.** A parallel session over TCP pays K
  full rustls handshakes (§16.8 measured this as part of the K>8
  flattening); QUIC pays one connection handshake, streams are nearly
  free.
- **Stream-level loss recovery.** A lost packet delays only its own
  stream's data, not every conversation behind a TCP head-of-line
  block. The netem blind spot from `evaluation/parallel.md` (shared
  bottleneck + loss) is *solved by the transport* here.
- **Connection migration.** A peer whose address changes (Wi-Fi →
  hotspot, NAT rebinding) keeps the connection alive — the "friend
  over the internet" case, impossible over TCP. This is the headline
  feature; it requires both peers on QUIC.
- **What it does not buy:** no throughput magic on clean paths (TCP at
  100 Mbit/s+ is rarely the bottleneck for ffsync's chunk sizes), and
  no LAN benefit (RTT is already ~0; §16.8's LAN caveat applies).
  QUIC is a WAN-shape transport; over TCP and QUIC both being
  available, the peers negotiate nothing — the CLI flags decide
  (`--quic` sender-side; a QUIC receiver listens on UDP and speaks
  QUIC to any peer that connects).

### 17.5 Interoperability and rollout

- **Discovery:** a QUIC receiver binds UDP port N and a TCP receiver
  binds TCP port N — the sender's flag picks the transport, there is
  no wire negotiation step (a QUIC sender connecting to a TCP-only
  peer fails at the UDP level, loudly: no QUIC handshake response).
- **v0.4/v0.5 peers:** no interaction — QUIC peers cannot talk to
  TCP-only peers (different transport), but the *protocol version*
  over QUIC is the current one; a future TCP peer of the same version
  interchanges identical bytes.
- **UDP blocked networks:** common in corporate/captive networks. The
  sender reports the handshake timeout with a hint ("QUIC needs UDP;
  try TCP (default) if the path blocks UDP"). ffsync always keeps the
  TCP transport; QUIC is opt-in per invocation, not a default.

### 17.6 Connection teardown (the one real behavioral difference)

TCP's `close()` flushes buffered data before FIN; QUIC's connection
close is **abrupt with respect to buffered stream data**: when a peer
closes the connection (or it is dropped), data accepted by
`SendStream::write` but not yet ACKed by the peer MAY be discarded
(RFC 9000 §10.2.2). Every protocol-level stream end is explicit here
(per-conversation FIN, ERROR frames), so the frame flows themselves
are unaffected — but a final frame that is *answered* (TREE_END
report, COMMIT_ACK) is only safely delivered if the writer holds the
connection until the reader's reply or close is observed. The
implementation therefore:

- keeps the session's `quinn::Connection` handle alive until after the
  last reply is read (senders: TREE_END report; receivers: the
  post-commit drain),
- receivers wait (bounded) for the peer to close before exiting, so a
  sender's final frames are ACKed rather than lost to a fast process
  exit, and
- treats stream FIN (`finish()` + drop, like TCP close) as the
  per-conversation end — the receiver's Eof path needs nothing extra.